MessageModule에 Inversion of Control 적용하기

Q.

NestJS에서 서비스 MessageService에 의존하는 Client가 과도하게 많은 A의 의존성을 함께 의존하게 되는 문제가 발생하여 의존성 역전과 Inversion of Control을 통해 결합도를 줄이고자 한다.

AS-IS

MessageService는 여러개의 UseCase들에게 직접 의존성을 가지고 있다. 각 UseCase들은 Client가 Service에게 전달해준 correlationId, context, payload 등을 ISendMessageCommand 타입 커맨드 객체로 변환하는 역할을 가지고 있으며, 이 과정에서 Repository, 타 Service, 타 UseCase에 의존하게 된다. 결과적으로 Client는 본인은 관심없는 MessageModule의 여러 Usecase들이 의존하는 모든 컴포넌트에 함께 의존하게 된다.

TO-BE

MessageService는 단지 ISendMessage 인터페이스에 의존한다. 따라서 Client는 전송하고 싶은 메시지가 있다면 ISendMessage를 구현한 UseCase 객체를 찾아 MessageService에게 대리자 형태로 전달하게 된다.

QUESTION

Chat GPT 5 Answer

결론:

  1. DIP를 올바르게 적용하면 Client의 과도한 결합은 해소된다. 단, UseCase 인스턴스를 Client가 직접 선택·생성하면 결합이 이동할 뿐이다. 해결책은 MessageService 내부의 핸들러 레지스트리로 라우팅하는 것.

  2. 책임 배분은 “UseCase가 커맨드 변환”보다 “UseCase는 도메인 의사결정만, 채널 어댑터는 Service가 선택”이 장기 유지보수에 유리하다. 즉, UseCase → Canonical DTO, MessageService → 채널 전략/어댑터 선택.

권장 구조(포트/어댑터 + 전략)

핵심 타입

// Command: Client는 이것만 안다
export interface ISendMessageCommand {
  type: 'INVITE' | 'REFUND' | 'OTP' | string;
  correlationId: string;
  context: Record<string, unknown>;
  payload: unknown;
}

// 핸들러 입력 포트
export interface ISendMessageHandler<C extends ISendMessageCommand = ISendMessageCommand> {
  readonly type: C['type'];
  handle(cmd: C): Promise<CanonicalMessage>;
}

// 표준 DTO: 채널 독립
export type CanonicalMessage = {
  recipients: string[];                 // phone, token 등
  templateId?: string;
  text?: string;
  data?: Record<string, unknown>;       // 템플릿 변수
  meta?: { correlationId: string; tags?: string[] };
};

// 출력 포트: 채널 게이트웨이
export interface IMessageGateway {
  readonly channel: 'SMS' | 'FCM' | 'EMAIL';
  send(msg: CanonicalMessage): Promise<{ messageId: string }>;
}

MessageService: 레지스트리 + 전략

@Injectable()
export class MessageService {
  constructor(
    @Inject('HANDLER_REGISTRY') private readonly handlers: Map<string, ISendMessageHandler>,
    @Inject('GATEWAYS') private readonly gateways: IMessageGateway[], // 다중 주입
    @Inject('CHANNEL_POLICY') private readonly pick: (msg: CanonicalMessage) => 'SMS'|'FCM'|'EMAIL',
  ) {}

  async send(cmd: ISendMessageCommand) {
    const handler = this.handlers.get(cmd.type);
    if (!handler) throw new Error(`No handler for type=${cmd.type}`);

    const canonical = await handler.handle(cmd);
    const channel = this.pick(canonical);
    const gw = this.gateways.find(g => g.channel === channel);
    if (!gw) throw new Error(`Gateway not found for channel=${channel}`);

    return gw.send(canonical);
  }
}

핸들러 등록 패턴(토큰+팩토리)

export const HANDLER_REGISTRY = {
  provide: 'HANDLER_REGISTRY',
  useFactory: (handlers: ISendMessageHandler[]) => {
    const map = new Map<string, ISendMessageHandler>();
    handlers.forEach(h => map.set(h.type, h));
    return map;
  },
  inject: [ 'INVITE_HANDLER', 'REFUND_HANDLER', /* ... */ ],
};

@Module({
  providers: [
    // 개별 핸들러
    { provide: 'INVITE_HANDLER', useClass: InviteSendHandler },
    { provide: 'REFUND_HANDLER', useClass: RefundSendHandler },
    HANDLER_REGISTRY,

    // 게이트웨이들
    { provide: 'GATEWAYS', useClass: SmsGateway, multi: true },
    { provide: 'GATEWAYS', useClass: FcmGateway, multi: true },

    // 채널 선택 정책(전략)
    { provide: 'CHANNEL_POLICY', useValue: (m: CanonicalMessage) => m.templateId ? 'SMS' : 'FCM' },

    MessageService,
  ],
  exports: [MessageService], // Client는 이것만 의존
})
export class MessageModule {}

핸들러 예시(UseCase는 도메인에 집중)

@Injectable()
export class InviteSendHandler implements ISendMessageHandler<InviteCommand> {
  readonly type = 'INVITE' as const;
  constructor(
    private readonly repo: LessonRepo,        // 도메인 의존
    private readonly tmpl: TemplateService,   // 도메인 규칙/표준 DTO 준비
  ) {}

  async handle(cmd: InviteCommand): Promise<CanonicalMessage> {
    const lesson = await this.repo.get(cmd.payload.lessonId);
    const text = this.tmpl.render('invite', { title: lesson.title, host: lesson.host });
    return {
      recipients: cmd.payload.phones,
      text,
      meta: { correlationId: cmd.correlationId, tags: ['invite'] },
    };
  }
}

1) DIP로 결합도 해소 여부

2) 책임 배분 비교

A. “UseCase가 커맨드 → 채널별 어댑터까지 직접 의존”

B. “UseCase는 Canonical DTO만 반환, 어댑터 선택은 Service”

권장: B. UseCase는 표준 DTO 생성 책임. 채널 선택과 어댑터 의존성은 MessageService의 전략으로 역전.

실무 팁

요약:

Client 예시 스니펫

// client.ts

import { MessageService } from '@/message/message.service';
import { ISendMessageCommand } from '@/message/contracts';

// 예시 커맨드 객체
const command: ISendMessageCommand = {
  type: 'INVITE',
  correlationId: 'abc-123',
  context: { lessonId: 'L001', inviter: 'hostUserId' },
  payload: { phones: ['01012345678'], name: '홍길동' },
};

// NestJS DI로 주입받은 MessageService 사용
export class SomeClient {
  constructor(private readonly messageService: MessageService) {}

  async invite() {
    const result = await this.messageService.send(command);
    console.log('message sent:', result);
  }
}